feat(surveys): re-translate displayed survey on language change#686
feat(surveys): re-translate displayed survey on language change#686bs1180 wants to merge 12 commits into
Conversation
Once a survey was on screen, later updates to the user's `language` person property had no effect — the translation was resolved only at display time. Now the SDK re-resolves the survey language whenever the person properties used for flags change and updates the on-screen survey in place, preserving the current question and progress. - Post an internal notification when person properties for flags change. - Survey integration observes it, re-resolves translations for the active survey, and pushes an in-place update only when the matched language actually changed (so `$survey_language` and renders aren't churned needlessly). - Add optional `updateSurvey(_:)` to `PostHogSurveysDelegate`; the default delegate updates the live survey via the display controller. The sheet now observes the controller so re-translations render without restarting. Closes #66998 Generated-By: PostHog Code Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
|
posthog-ios Compliance ReportDate: 2026-07-17 07:48:13 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
- Keep PostHogSurveyIntegration.swift under the 1200-line SwiftLint limit by moving self-contained helper types (survey state, condition matching, rating buckets) into a new PostHogSurveyMatching.swift, registered in the Xcode project. - Use the non-availability-gated `_surveysConfig` backing in tests; the public `surveysConfig` accessor is iOS 15+ only and broke the simulator build. - Add the new optional `updateSurvey(_:)` delegate method to the public API snapshot. Generated-By: PostHog Code Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
Address Greptile review on #686: - When a survey is within its display-delay window and its language updates, `updateSurvey` refreshes `pendingSurvey`, but the scheduled work item showed the captured (pre-update) copy. Read the latest `pendingSurvey` at execution time so the re-translated content is shown. - Distinguish the "no survey displayed" vs "different survey" cases in the display controller's update log to make debugging clearer. Generated-By: PostHog Code Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
|
@lucasheriques I know you worked a bit on this, mind giving this a review? |
|
Reviews (2): Last reviewed commit: "fix(surveys): apply latest translation w..." | Re-trigger Greptile |
|
Thanks @bs1180 instead of a CHANGELOG we actually need a changes, please see |
|
@bs1180 friendly ping on this one |
Follow the repo's Changesets release flow: add a minor changeset describing the live survey re-translation feature and revert the direct CHANGELOG.md edit (the release workflow regenerates CHANGELOG.md from changesets). Generated-By: PostHog Code Task-Id: 50affc3a-7798-47a6-94fe-c86b2b971dac
|
Hey @turnipdabeets and @ioannisj, sorry for the delay. This PR is the product of letting PostHog AI loose on a customer complaint :D I'm not an IOS dev so not sure if this implementation makes sense and I've done literally zero QA. Is that OK with you? Won't be offended this just gets rejected. |
|
@bs1180 thank you, I can do the QA part and see also how this will affect Flutter surveys as well. we'll probably need to land a similar feature for posthog-android as well I imagine? To get some more context though, do you have the original link to the customer complaint? I personally don't think that on-the-fly language changes are the norm so I'm not completely sure we should be supporting this |
|
Would like to know @turnipdabeets thoughts as well on this, maybe it's just me :) |
|
Yup totally understood - PostHog/posthog#66998 is the original feature request, and this is the customer Slack thread |
I think we need on-device QA, if you want to take that @ioannisj. Maybe we need to check out the sheet dismissal animation too. Not sure if we need @PostHog/team-surveys on par before making this change? And it makes sense to do this for the other mobile if it's needed, android flutter and RN. |
ioannisj
left a comment
There was a problem hiding this comment.
LG. Tested this on iOS and Flutter and left some minor comments. We'll probably want to follow this up on Android and Flutter side
|
Hey @bs1180, want me to take this over if you've moved on to something else? |
|
Hey @ioannisj, sorry didn't see your latest comments. Yes please - as warned I'm not a mobile dev so my next steps would be to just keep prompting in Posthog Code :D Thank you! |
…ive-language-update
… instead of NotificationCenter
| displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .multipleChoice(resp)) | ||
| } | ||
| } else { | ||
| SingleChoiceQuestionView(question: currentQuestion) { resp in |
There was a problem hiding this comment.
The PR note says a language switch can drop an in-progress choice selection, but it's a bit sharper than that: selectedChoices in the choice views is @State Set<String> keyed by label text and survives the in-place swap — so after a switch the highlight vanishes but canSubmit stays true, and submitting records the stale old-language label. For the open choice, response == openChoice compares the old label against the new-language open choice, fails, and submits the label instead of the typed text. Could we key selection by choice index, or clear/remap it in .onChange(of: question.choices)?
| let onNextQuestionClicked: (_ index: Int, _ response: PostHogSurveyResponse) -> Void | ||
| // Observed so the sheet re-renders when the displayed survey is updated in place | ||
| // (e.g. re-translated after a language change), not just when questions advance. | ||
| @ObservedObject var displayManager: SurveyDisplayController |
There was a problem hiding this comment.
[suggestion] Now that the sheet observes the controller live, dismissSurvey()'s synchronous reset of currentQuestionIndex/isSurveyCompleted re-renders question 1 of fallbackSurvey during the slide-out (pre-PR, the let snapshots kept the outgoing frame frozen). Since showSurvey re-initializes both anyway, could we drop those two resets from dismissSurvey()?
| class TestLiveTranslationUpdate { | ||
| let server: MockPostHogServer | ||
|
|
||
| init() { |
There was a problem hiding this comment.
[suggestion] postHog.close()/reset() at the tail of each test body get skipped when a #require throws (there's one in getSurveyIntegration on every path), leaking a live SDK and its subscription into the next .serialized test. The suites in PostHogSurveysTest.swift build the SDK in init() and tear down in deinit — could we do the same here?
|
|
||
| guard let displaySurvey else { return } | ||
|
|
||
| DispatchQueue.main.async { |
There was a problem hiding this comment.
[suggestion] The lock comment above guarantees commit order, but this enqueue happens after the lock is released — two racing refreshes can commit fr→de yet enqueue de→fr, leaving the screen on fr while activeSurveyLanguage (and $survey_language) say de. Moving the DispatchQueue.main.async inside the withLock block makes enqueue order match commit order (async enqueue doesn't block).
| let translations = resolveSurveyTranslations(survey: activeSurvey, targetLanguage: language) | ||
|
|
||
| // Commit only if the update targets a different language than what's currently shown | ||
| guard self.activeSurveyLanguage != translations.matchedKey else { return nil } |
There was a problem hiding this comment.
[question] If a refresh lands in the window after setActiveSurvey but before the initial renderSurvey reaches the controller, the language commits here but the dispatched update is dropped (displayedSurvey is nil, no pending survey) — and the next refresh no-ops since the language already matches. Tiny window, sticky outcome. Worth reconciling in handleSurveyShown when the shown snapshot's language differs from activeSurveyLanguage?
| /// keeps the language it was first rendered with. | ||
| /// | ||
| /// - Parameter survey: The survey with refreshed (e.g. re-translated) content. Its `id` | ||
| /// matches the survey passed to `renderSurvey`. |
There was a problem hiding this comment.
[suggestion] Two contract points a custom-delegate author needs here: this is always delivered on the main thread, and it can arrive before the survey is visibly shown (e.g. during surveyPopupDelaySeconds — that's why the default delegate refreshes pendingSurvey). A delegate with its own display delay will silently drop the update otherwise. Could we add both to the doc comment?
| // Notify subscribers (e.g. surveys) so a survey already on screen can re-resolve its | ||
| // language if the user's `language` property changed. Invoked outside the lock so | ||
| // subscribers don't run while we hold it. | ||
| onPersonPropertiesForFlagsChanged.invoke(()) |
There was a problem hiding this comment.
[nit] This fires even when the merge changed nothing, so every capture() carrying userProperties re-runs translation resolution under activeSurveyLock while a survey is up. Skipping the invoke when no merged key changed value would keep it off hot paths.
| // Commit only if the update targets a different language than what's currently shown | ||
| guard self.activeSurveyLanguage != translations.matchedKey else { return nil } | ||
| self.activeSurveyLanguage = translations.matchedKey | ||
| self.activeSurveyQuestionTranslations = translations.questions |
There was a problem hiding this comment.
[question] Once this commits mid-survey, survey sent stamps all $survey_questions (and $survey_language) with the final translations — answers given before the switch are reported with question text the user never saw, and survey shown vs sent disagree on the language. Acceptable for analytics, or should we freeze the language after the first response?
💡 Motivation and Context
Once a translated survey was displayed, later changes to the user's language (e.g. via
setPersonProperties/identifywith alanguageproperty) had no effect — the survey kept the language it was first rendered with. The translation was resolved only at display time.This makes a survey already on screen follow the user's language: when the person properties used for flags change and a matching translation exists, the survey re-translates in place, keeping the current question and any progress.
Closes #66998
💚 How did you test it?
Added unit tests (run on the iOS simulator, where survey code compiles):
languageperson property while a survey is active pushes a re-translated survey to the delegate and updates the tracked survey language.$survey_languageor re-renders).SurveyDisplayController.updateSurveypreserves the current question index and completion state, and ignores updates for a different / absent survey.I was unable to run
make test/ the iOS build in this environment (no Xcode available), so CI should be relied on for the full build + simulator test run.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Implemented from a Slack thread request, logged as #66998. Built with the PostHog Slack app (Claude Code). Key decisions:
NotificationCentersignal posted fromPostHogRemoteConfig.setPersonPropertiesForFlags(the single chokepoint all person-property updates flow through) rather than sniffing the captured event stream — the$set/$identifyordering relative to the flag-property update is inconsistent, so the notification is the order-correct, robust hook.updateSurvey(_:)to the delegate and madeSurveySheetobserve the display controller (instead of the snapshot passed by.sheet(item:)), so an in-place content swap with the same survey id renders live while preserving SwiftUI@State(current question, typed answers).One known limitation worth a reviewer's eye: for choice questions, selection is keyed by the option's string value, so a mid-survey language switch can drop an in-progress selection on those question types (labels change). Text/rating answers are preserved.
Created with PostHog from a Slack thread